"""
File: converter.py
Description: NCM to audio (mp3/flac) converter core utilities.
This module parses NetEase .ncm files and extracts the original audio using
AES-based decryption and a key-box stream cipher as discovered by community.

All comments and docstrings are in English per requirement.
"""

import os
import json
import base64
import binascii
import struct
from typing import Callable, Dict, Optional

from Crypto.Cipher import AES


class NCMConverter:
    """Core class to convert .ncm files to their original audio format.

    Responsibilities:
    - Parse NCM header and metadata
    - Decrypt key data using AES (ECB)
    - Construct key-box for stream cipher
    - Decrypt audio content chunk-by-chunk
    - Report progress via callback
    """

    # Constants discovered by reverse engineering (public community knowledge)
    _CORE_KEY = binascii.a2b_hex(b"687A4852416D736F356B496E62617857")
    _META_KEY = binascii.a2b_hex(b"2331346C6A6B5F215C5D2630553C2728")

    def __init__(self) -> None:
        """Initialize converter instance (stateless)."""

    @staticmethod
    def _unpad(data: bytes) -> bytes:
        """Remove PKCS#7 padding from decrypted data.

        Args:
            data: Decrypted bytes that may have PKCS#7 padding.
        Returns:
            Bytes with padding removed.
        """
        pad_len = data[-1] if isinstance(data[-1], int) else ord(data[-1])
        return data[:-pad_len]

    def get_meta(self, file_path: str) -> Optional[Dict]:
        """Read and decrypt metadata only, without converting audio.

        Args:
            file_path: Path to the .ncm file.
        Returns:
            Dict with metadata (e.g., musicName, format) or None on error.
        """
        try:
            with open(file_path, "rb") as f:
                header = f.read(8)
                if binascii.b2a_hex(header) != b"4354454e4644414d":
                    return None
                # Skip 2 bytes version(?), then read key length and key block
                f.seek(2, 1)
                key_length = struct.unpack("<I", f.read(4))[0]
                key_data = bytearray(f.read(key_length))
                for i in range(len(key_data)):
                    key_data[i] ^= 0x64
                # Decrypt key with core key (ECB)
                cryptor = AES.new(self._CORE_KEY, AES.MODE_ECB)
                key_data = self._unpad(cryptor.decrypt(bytes(key_data)))[17:]

                # Prepare key-box (needed later, but meta can be read without it)
                # Read meta length and block
                meta_length = struct.unpack("<I", f.read(4))[0]
                meta_data = bytearray(f.read(meta_length))
                for i in range(len(meta_data)):
                    meta_data[i] ^= 0x63
                meta_data = base64.b64decode(bytes(meta_data)[22:])
                cryptor = AES.new(self._META_KEY, AES.MODE_ECB)
                meta_json = self._unpad(cryptor.decrypt(meta_data)).decode("utf-8")[6:]
                return json.loads(meta_json)
        except Exception:
            return None

    def convert_file(
        self,
        file_path: str,
        output_dir: Optional[str] = None,
        progress_cb: Optional[Callable[[float], None]] = None,
    ) -> Dict:
        """Convert a single .ncm file to its original audio file.

        Args:
            file_path: Path of the .ncm file to convert.
            output_dir: Directory to write the output file (defaults to source folder).
            progress_cb: Optional callback receiving percentage float [0, 100].
        Returns:
            Dict with keys: success (bool), message (str), output_path (str), meta (dict or None).
        """
        result = {"success": False, "message": "", "output_path": "", "meta": None}
        try:
            with open(file_path, "rb") as f:
                header = f.read(8)
                if binascii.b2a_hex(header) != b"4354454e4644414d":
                    result["message"] = "Invalid NCM header"
                    return result

                # Skip 2 bytes
                f.seek(2, 1)

                # Read and decrypt key block
                key_length = struct.unpack("<I", f.read(4))[0]
                key_data = bytearray(f.read(key_length))
                for i in range(len(key_data)):
                    key_data[i] ^= 0x64
                cryptor = AES.new(self._CORE_KEY, AES.MODE_ECB)
                key_data = self._unpad(cryptor.decrypt(bytes(key_data)))[17:]

                # Build key box
                key_box = bytearray(range(256))
                key_length2 = len(key_data)
                c = 0
                last_byte = 0
                key_offset = 0
                for i in range(256):
                    swap = key_box[i]
                    c = (swap + last_byte + key_data[key_offset]) & 0xFF
                    key_offset += 1
                    if key_offset >= key_length2:
                        key_offset = 0
                    key_box[i] = key_box[c]
                    key_box[c] = swap
                    last_byte = c

                # Read and decrypt meta block
                meta_length = struct.unpack("<I", f.read(4))[0]
                meta_data = bytearray(f.read(meta_length))
                for i in range(len(meta_data)):
                    meta_data[i] ^= 0x63
                meta_data = base64.b64decode(bytes(meta_data)[22:])
                cryptor = AES.new(self._META_KEY, AES.MODE_ECB)
                meta_json = self._unpad(cryptor.decrypt(meta_data)).decode("utf-8")[6:]
                meta = json.loads(meta_json)
                result["meta"] = meta

                # Skip CRC(4), un-known(5)
                f.seek(9, 1)

                # Read image block
                image_size = struct.unpack("<I", f.read(4))[0]
                f.seek(image_size, 1)  # Skip image data

                # Prepare output path
                base_dir = output_dir or os.path.dirname(file_path)
                src_base = os.path.splitext(os.path.basename(file_path))[0]
                out_ext = meta.get('format', 'mp3')
                out_name = f"{src_base}.{out_ext}"
                out_path = os.path.join(base_dir, out_name)
                # Ensure output directory exists
                os.makedirs(base_dir, exist_ok=True)

                # Convert audio data in chunks with progress
                total_size = os.path.getsize(file_path)
                processed = 0
                with open(out_path, "wb") as m:
                    while True:
                        chunk = bytearray(f.read(0x8000))
                        if not chunk:
                            break
                        chunk_len = len(chunk)
                        for idx in range(1, chunk_len + 1):
                            j = idx & 0xFF
                            chunk[idx - 1] ^= key_box[
                                (key_box[j] + key_box[(key_box[j] + j) & 0xFF]) & 0xFF
                            ]
                        m.write(chunk)
                        processed += chunk_len
                        if progress_cb is not None and total_size > 0:
                            # Estimate progress by file read position / total size
                            percent = min(100.0, max(0.0, (f.tell() / total_size) * 100.0))
                            progress_cb(percent)

                result["success"] = True
                result["output_path"] = out_path
                result["message"] = "OK"
                return result
        except Exception as e:
            result["success"] = False
            result["message"] = f"Error: {e}"
            return result


def scan_folder(folder: str) -> Dict[str, Dict]:
    """Scan a folder for .ncm files and collect basic info and metadata.

    Args:
        folder: Directory to scan.
    Returns:
        Mapping from file path to info dict {name, size_bytes, meta}.
    """
    conv = NCMConverter()
    files: Dict[str, Dict] = {}
    for root, _, names in os.walk(folder):
        for n in names:
            if n.lower().endswith(".ncm"):
                p = os.path.join(root, n)
                meta = conv.get_meta(p)
                files[p] = {
                    "name": n,
                    "size_bytes": os.path.getsize(p),
                    "meta": meta,
                }
    return files